前幾天把記憶做得很完美,讓Agent能自動提取、矛盾偵測、過期淘汰。但在Day 25 時我心中一直在想一件事
這整套機制,使用者完全看不見。
她默默記下「使用者討厭吃香菜」、「使用者下週二有期末考」,而使用者不知道被記了什麼、不能修正、也不能刪。這在功能上叫長期記憶,在使用者體驗與隱私設計上,這就成了使用者不知道系統記錄了什麼的問題。
所以 Day 28 不只是「UI 美化」。介面當然要做,但真正該補的是那條缺失的權利:
記得住是功能,讓人查得到才是責任。
persona.THEME_COLOR,跟著角色即時切換。st.metric 加上 st.progress。test_day28.py + 用瀏覽器真的點過一輪。原本的計畫是 THEME_COLOR = "#D9578E" 然後注入 CSS。查了 Streamlit 官方指引才發現這是反模式,主題應該走 config.toml,不要自己塞 <style>。
但 config.toml 是啟動時讀的靜態設定,做不到「換角色就換色」。折衷的答案是用 Streamlit 的語意色名稱:
# persona_lily.py
THEME_COLOR = "orange" # 莉莉取暖色系
# persona_shike.py
THEME_COLOR = "violet" # 時刻取紫,對應她深沉神秘的調性
st.markdown(f":{persona.THEME_COLOR}-badge[:material/person: {persona.DISPLAY_NAME}]")
好處不只是「照官方建議做」:語意色會跟著使用者的淺色/深色模式自動換算,寫死的 hex 在深色模式下常常糊成一團。 基礎配色則老老實實寫進 config.toml,而且同時定義 [theme.light] 與 [theme.dark],只寫一組會把 app 鎖死在單一模式。
好感度那邊也順手改了。原本只有 st.metric 顯示「29 / 100」,現在加上 st.progress,使用者要的其實是「離下一個狀態還有多遠」的感覺,不是精確到個位數的數值。狀態文字也從純文字改成主題色 badge,切角色時整條側邊欄的色調會跟著換。
改完的側邊欄長這樣,角色 badge、好感度進度條、狀態 badge、以及那顆「查看記憶庫」按鈕:
Day 25 立過一條原則:記憶只封存不刪除,因為自動判斷可能出錯,封存可逆、delete 不可逆。
但這條原則不能整套搬到使用者身上。他按下「刪除」,要的是消失,不是藏起來。把使用者的刪除也做成封存,等於嘴上答應、資料還留著。
記憶庫本身放在 st.dialog 而不是塞進側邊欄。側邊欄已經有角色選擇和心情儀表板,再擠進一份可捲動的清單會兩邊都難用;而且記憶庫是「偶爾才會打開來檢查」的東西,不需要常駐佔版面。
所以資料層新增兩支語意相反的 API:
def restore_memory(memory_id: str):
"""把封存的記憶放回檢索範圍,Day 25 說「封存可逆」,這裡兌現成按鈕"""
def delete_memory(memory_id: str):
"""真正刪除,不可復原。
系統自動判斷的「忘記」→ 封存(可能判錯,要留退路)
使用者親手按的「刪除」→ 真刪(他要求的是消失)"""
介面上兩者的份量也不同:封存是普通按鈕,刪除要跳出「刪除後無法復原,確定嗎?」。
還有一件小事但影響很大,封存原因原本直接顯示 contradicted / expired / evicted。使用者看不懂這些字,而「看不懂為什麼不見了」跟「壞掉了」在體感上沒有區別。

test_day28.py 六案例全過。但 UI 不是跑腳本能驗的東西,所以我開了瀏覽器真的點一遍,結果兩個坑都是這樣才發現的。
坑一:按下「封存」,視窗直接關掉。
功能是對的(記憶確實封存了),但 st.rerun(scope="app") 會重跑整頁,dialog 跟著消失。使用者看不到結果,也沒辦法連續封存第二筆。
st.rerun(scope="fragment") # dialog 底層是 fragment,只重繪視窗內容
改完之後,清單即時更新、視窗留在原地、上方計數從「有效 2、封存 1」變成「有效 3、封存 0」。
坑二:畫面印出「原因:user_archived」。
這個更值得記,因為測試明明過了。原因是我在測試裡寫的 fallback 跟 UI 寫的不一樣:
# 測試:查不到就給通用說明 → 看起來正常
memory_ui.REASON_LABELS.get(r, "使用者手動封存")
# UI:查不到就顯示原始代碼 → 畫面漏出 user_archived
REASON_LABELS.get(m["reason"], m["reason"] or "使用者手動封存")
修法不是補上那個 key 就算了,而是把規則抽成一個函式 reason_label(),讓 UI 與測試共用同一條規則:
def reason_label(reason: str) -> str:
"""查不到就回通用說明,絕不把原始代碼原封不動印出去"""
return REASON_LABELS.get(reason, "系統自動封存")
測試與實作各寫一份 fallback,測到的就不是實際會發生的事。
順帶還撞到一個 ChromaDB 的行為:update 是合併 metadata 而不是取代。復原時我以為「在 dict 裡不寫 archived_reason 就等於清掉它」,實際上完全沒動,要明確傳 None 才會真的移除。

Day 28 掛的是「UI 美化」,做起來卻是整季離「產品」最近的一天 (沒有要販賣)。
因為使用者不在乎你的記憶架構有幾層。 他在乎的是:你記了我什麼?可以改嗎?可以刪嗎?前面五天做的所有治理邏輯,到今天才第一次變成使用者按得到的東西。
第二個收穫是關於驗證方式:UI 的坑,只有真的點下去才會出現。 那兩個坑(視窗被關掉、原始代碼漏出畫面)在腳本裡全都測不到,一個是 Streamlit 的重跑行為,一個是測試自己抄錯了 fallback。我原本以為開瀏覽器是「順便看一下漂不漂亮」,結果它是今天唯一抓到 bug 的手段。
測試過了不代表使用者看到的是對的
做記憶庫介面時,我第一次以「使用者」而不是「開發者」的角度看那三筆記憶。「使用者的名字是小明」「使用者是資工系的學生」「使用者正在準備下週二的期末考」。這些句子是我自己設計的提取格式,讀了幾十次,但排在一個有分類標籤、有日期、旁邊還有刪除按鈕的清單裡看,這裡感受完全不一樣:不再是「資料」,而是「有人記著我這些事」。
(註:本文僅刊登關鍵段落
完整原始碼包含 memory_ui.py、rag_memory.py 與 test_day28.py),太長了所以一樣不放